[AD] Scalaアプリケーションの開発・保守は合同会社ミルクソフトにお任せください
この記事では副作用について解説します。
副作用とは主たる作用とは別に発生する作用である
「副作用」とはなんでしょうか?
副作用というと一般的に薬を服用した際に期待する効果とは別の症状が出ることを思い浮かべることでしょう。
例えば、風邪の治療薬の場合は風邪を治療することが主たる作用(期待する効果)ですが、それ以外に肌が荒れる症状が出た場合のそれは副作用(期待する効果とは別の症状)になります。
では、「プログラミングにおける副作用」とは何でしょうか?
プログラミングでは式、関数を評価し値を得る際の何らかの効果を「作用」と呼びます。
式、関数を評価し値を得ることを期待する効果が、「主たる作用」です。
それ以外の状態(グローバル変数など)を変化させる作用が「副作用」です。
以下は副作用のあるコードの例です。
def add(x: Int): Int = total = total + x total
この関数は関数外の変数total
に引数x
の値を足して、total
を返しています。
この関数の主たる作用は「2つの値を足した値を返す」ですが、主たる作用以外に変数total
の値を更新しているので「副作用のある関数」です。
副作用があるとテストが難しい
副作用があるコードはテスト(単体テスト)を実行することが難しいです。
それはなぜでしょうか?
先程の関数add
を例に考えてみます。
以下の戻り値res
の値はいくつでしょうか?
val res = add(1)
これだけでは戻り値res
の値はわかりませんね。
先程の関数add
の戻り値は関数外の変数total
の値に依存しているので、戻り値は変数total
の値によって違ってきます。
そのため、戻り値の値は関数外の変数total
の値を考慮してチェックする必要があります。
関数add
をテストするには、変数total
の値を初期化してから関数add
を呼び出す必要があります。
total = 2 val res_2 = add(1) assert(res_2 == 3)
この例だとそれほど問題があるように思えないかもしれません。
次の例ではどうでしょうか?
この関数add2
の主たる作用は、先程の関数add
と同じで「2つの値を足した値を返す」です。
def add2(x: Int): Int = var total = Storage.get() total = total + x Storage.put(total) total
関数add2
は何らかのストレージ(ファイルやデータベースなど)の値に引数x
の値を足してその値を返しています。
この関数をテストするにはストレージが必要になります。
テストコードでは以下のようにストレージの値を初期化してから関数を呼び出す必要があります。
また、テスト後にはストレージの終了処理が必要になるかもしれません。
// ストレージの初期化処理 Storage.create() Storage.init(2) val res = add2(1) assert(res == 11) // ストレージの終了処理 Storage.delete() Storage.close()
この例では、副作用があることにより、事前条件を整えたり、ストレージの初期化処理、終了処理が必要になりました。
当然、実装にかかる時間や手間、毎回のテストにかかる計算量や時間も増大しています。
副作用があるとテストが難しくなることが理解できたでしょうか。
副作用があると予期せぬ動作となる場合がある
副作用があるとプログラムが「予期せぬ動作」となる場合があります。
予期せぬ動作とは何でしょうか?
再度、先程の関数add
を見てみます。
def add(x: Int): Int = total = total + x total
以下のように関数を呼び出した結果、変数res
の値はいくつでしょうか?
total = 2 val res_3 = add(1)
普通に考えると変数res
の値は3になると思います。
ですが、3にならないケースがあります。
変数total
は値を書き換えているため、「可変」です。
関数add
では、以下のように2ステップのコードになっています。
- 変数
total
に引数x
を加えた値を保管 - 変数
total
の値を返す
では、1と2の間に他(例えば別のスレッド)からtotal
の値を10に書き換えた場合について考えてみましょう。
関数add
の戻り値は10+1の11となり、当初期待していた3とは違っています。
このため、このタイミングで関数add
を使用していたプログラムは正常に動作しないと考えられます。
副作用をコードから排除する
前述の説明から副作業のあるコードには問題があることがわかりました。
では、先程の関数add
から「副作用を排除」してみます。
関数add
の主たる作用は「2つの値を足した値を返す」でした。
よって、2つの値を引数で受け取り足した値を返すようにすれば、「副作用のない関数」になります。
def add(x: Int, y: Int): Int = x + y
このように主たる作用のみの関数を「純粋関数」と呼びます。
純粋関数は引数が同じ場合、常に同じ結果を返します。
そのため、テストも以下のように関数を呼び出した結果をチェックするだけになり簡単になります。
val res = add(1, 2) assert(res == 3)
完全に副作用がないプログラムは意味がない
副作用をコードから排除する方法はわかりました。
では、変数total
の更新やストレージの更新など「副作用のある処理」はどうすればよいでしょうか?
「副作用のあるコードは使う箇所を必要最小限にする」のが良いです。
先程のストレージの値を更新するケースを例に副作用のあるコードを局所化する例を見てみます。
副作用のあるストレージへアクセスする部分を関数にします。
この関数はストレージの値を引数の関数に適用して結果をストレージに保管します。
こうすることで値を操作する箇所とストレージに保管する箇所が分離され、副作用のあるコードを局所化することができます。
def withStorage(f: (Int) => Int): Unit = Storage.open() val res = f(Storage.get()) Storage.put(res) Storage.close()
関数add
を使用する場合は以下のように呼び出します。
withStorage(add(1, _))
また、この関数withStorage
の引数は「Int
型の引数を受け取ってInt
型の値を返す関数」であればよいので、以下のように「値を2倍する関数」を指定することもできます。
withStorage(x => x * 2)
副作用のあるコードはテストをするのが大変ですが、副作用のあるコードを最小限にすることでテストのコストを減らすことができます。
副作用のないプログラミングを心がけるとよい
副作用の無いプログラミングを心がけると、テストがしやすく予期せぬ動作がないコードを書くことができます。
結果、バグが少なくメンテナンスのしやすいコードになります。
ただし、副作用にはデメリットが多い一方で、実装次第では効率的なプログラムが書ける場合があります。
パフォーマンスが求められる場面においては、副作用のあるプログラミングも検討してみましょう。